Skip to content

Id-keyed TagMap.set(long) + insertion-comparison benchmark#11901

Draft
dougqh wants to merge 12 commits into
dougqh/tag-registry-generatorfrom
dougqh/tag-id-api
Draft

Id-keyed TagMap.set(long) + insertion-comparison benchmark#11901
dougqh wants to merge 12 commits into
dougqh/tag-registry-generatorfrom
dougqh/tag-id-api

Conversation

@dougqh

@dougqh dougqh commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

What

On top of the bloom fast-path (#11900):

  1. TagMap.set(long id, Object) — the id-keyed insertion path: given a resolved KnownTags.*_ID, store densely and skip keyOf name resolution. Sole TagMap impl (OptimizedTagMap); the id must be a stored known id (custom names keep the name setters).
  2. TagMapInsertionComparisonBenchmark — HashMap / 1.0-bucket / 2.0-name / 2.0-id, alloc + throughput; the numbers behind the deck's slides 3/7/8.

Results (idle box, -prof gc -f 5 -wi 5 -i 5; persisted in the benchmark header)

arm alloc B/op (7 / 12) thrpt vs HashMap (7 / 12)
hashMap 352 / 512 1.00× / 1.00×
tagMapById 184 / 408 0.99× / 0.63×
tagMapByName 184 / 408 0.66× / 0.50×
tagMapCustom (1.0 bucket) 416 / 712 0.59× / 0.46×
  • Alloc: dense ~half of HashMap; id == name (the bloom/dense win is CPU, not alloc).
  • Throughput: id-insertion reaches HashMap parity at typical tag counts (0.99× @7) and beats the 1.0 bucket path 1.4–1.7×. The 12-tag gap is a sizing/resize artifact (SpanPrototype's right-sizing / a larger init cap closes it), not a scan — the tags don't collide under the bloom's fieldPos & 63.

Scope

Minimal id API (TagMap level). Full AgentSpan.setTag(long) + the KnownTags call-site migration are follow-ons. set(long) has no production caller yet — by design, it lands with its benchmark evidence ahead of the migration that consumes it (each PR stands alone in sequence).

Stacking

#11814 (dense storage) → #11900 (bloom) → this (id API + comparison benchmark).

Follow-ups

TagMap.Ledger + SpanBuilder id support; per-type graph coloring; master run to pin true-1.0 (no keyOf).

🤖 Generated with Claude Code

@dougqh dougqh added comp: core Tracer core tag: ai generated Largely based on code generated by an AI or LLM tag: no release notes Changes to exclude from release notes type: refactoring labels Jul 9, 2026
@dd-octo-sts

dd-octo-sts Bot commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

🟢 Java Benchmark SLOs — All performance SLOs passed

Suite Status
Startup 🟢 pass

SLO thresholds are defined here based on automatically generated metrics. A warning is raised when results are within 5% of the threshold.

PR vs. master results
Scenario Candidate master Δ (95% CI of mean)
startup:insecure-bank:iast:Agent 13.96 s 13.98 s [-0.9%; +0.6%] (no difference)
startup:insecure-bank:tracing:Agent 12.92 s 13.03 s [-1.7%; +0.1%] (no difference)
startup:petclinic:appsec:Agent 16.95 s 16.87 s [-0.6%; +1.6%] (no difference)
startup:petclinic:iast:Agent 16.90 s 16.94 s [-1.1%; +0.7%] (no difference)
startup:petclinic:profiling:Agent 16.06 s 16.83 s [-9.1%; -0.1%] (maybe better)
startup:petclinic:sca:Agent 16.97 s 16.78 s [+0.1%; +2.3%] (maybe worse)
startup:petclinic:tracing:Agent 16.15 s 16.07 s [-0.5%; +1.4%] (no difference)

Commit: 7e3ed31f · CI Pipeline · Benchmarking Platform UI


Load and DaCapo benchmarks can be triggered manually in the GitLab pipeline. Results will appear in the Benchmarking Platform UI after completion.

dougqh and others added 6 commits July 15, 2026 10:54
create(Object)/create(CharSequence) may return null for a null or empty
value, and the Entry sinks -- getAndSet(Entry) / set(EntryReader) -- treat a
null Entry as a no-op, so a null/empty value flows through the Entry pathway
as "no tag" without any caller guarding. create(Object) now applies the
empty-CharSequence check by runtime type, so the null/empty => absent
convention holds regardless of the static type at the call site.

The strict (key,value) setters keep their contract -- their values are now
@nonnull -- so null tolerance is scoped to the Entry pathway. The annotations
make the split self-describing.

Fixes a latent NPE by construction: RemoteHostnameAdder sets
create(TRACER_HOST, hostname) guarding only null, not empty, so an empty
hostname previously NPE'd on set(null). Caller-side guard cleanup (incl. the
redundant #11958 guard) is left to a follow-up.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A tag has no valid null key, so put/set/getAndSet/create now take @nonnull
tag. This completes the null contract alongside the value/Entry side: keys
are strict (null = a bug), values/Entries on the Entry pathway are tolerant
(null = no tag). Scoped to the write/create surface; read/lookup keys
(getString, remove, getXxxOrDefault) are left for a possible follow-up.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
TagMap was an interface with a single implementation, OptimizedTagMap. The
split was vestigial scaffolding from when a second (HashMap-backed) impl
existed; with one impl it is false generalization. Collapse them into one
`public final class TagMap`:

- The interface's abstract method declarations are removed; OptimizedTagMap's
  bodies become TagMap's methods.
- Nested types that were implicitly `public static` in the interface
  (EntryChange, EntryRemoval, EntryReader, Entry, Ledger) are now written out
  explicitly as `public static`.
- Static factories (create/fromMap/ledger/...) and the EMPTY constant become
  explicit `public static` members; the EmptyHolder lazy-init note is updated
  now that there is no interface<->impl class-init cycle.
- putAll(TagMap) loses its `instanceof` dispatch (always true once there is one
  class) and calls the fast path directly.

No behavior change; motivation is code simplicity, not performance (a single
final class is monomorphic by construction, but CHA already devirtualized the
sole impl). Public API is preserved, so callers are unchanged; the 3 tests that
referenced OptimizedTagMap now reference TagMap.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Post-fold tidy, all TagMap-scoped:

- Remove EmptyHolder: with one class there is no interface<->impl class-init
  cycle to break, and the private constructor reads no statics, so EMPTY is a
  direct `new TagMap(new Object[1], 0)` initializer.
- Static factories (create/fromMap/ledger/...) are now `public static final`
  (not expressible on the old interface).
- assertSize/assertNotEmpty/assertEmpty/checkIntegrity test helpers dropped
  their now-always-true `instanceof TagMap` guard + redundant cast.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A fresh, mutable TagMap can read through to a frozen parent on local
misses, so a span can layer its own tags over a shared, immutable set
(e.g. merged tracer tags) without copying them.

- createFromParent(parent): the only way to attach a parent; the parent
  must be frozen and is fixed at construction (no re-parenting), so
  read-through can treat it as stable. Single-parent by design in phase 1.
- Reads resolve local-first, then the parent; a local entry shadows the
  parent's (local-wins). Removing a parent key locally records a lazy
  tombstone (removedFromParent) so it stops reading through; the tombstone
  set is null until first needed, keeping the hot paths untouched.
- size()/isEmpty() are exact (Map contract) and resolve the parent;
  isDefinitelyEmpty()/estimateSize() are the cheap conservative variants
  for the hot path. copy() preserves the parent and tombstones; forEach
  walks local then parent.

Built on the folded final-class TagMap (#11967); composes cleanly with the
null-tolerant Entry pathway (#11963).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@dougqh
dougqh force-pushed the dougqh/dense-bloom-filter branch from 428e72f to 267ebad Compare July 15, 2026 20:44
dougqh and others added 4 commits July 15, 2026 17:19
…plit phase 1)

Attach the trace's merged tracer tags to each span's TagMap as a frozen
read-through parent (via TagMap.createFromParent) at span construction,
instead of copying them into every span. The span sees the shared tags on
read and only stores its own local tags, so the common trace-level bundle
is held once per trace rather than duplicated per span.

- CoreTracer builds the frozen merged-tracer-tags parent once; config
  version is kept out of that bundle.
- DDSpanContext attaches the parent at construction (fixed, no re-parenting).
- Adds TagMapReadThroughBenchmark (copy-down vs read-through, -prof gc).

Stacked on the read-through mechanism (#11789), which builds on the folded
final-class TagMap (#11967).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
StringIndex is a compact open-addressed string→index structure (the keyOf
substrate the dense tag store builds on): parallel hash/name arrays, linear
probing, on par with HashSet on lookup at a smaller footprint. Includes unit
tests, a footprint test (jol), and comparison benchmarks (vs HashSet/switch).

No TagMap changes — standalone util. Rebased onto the level-split stack
(consumer #11932) as the layer dense-store sits on.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Known tags (keyOf resolves to a stored id) are held in insertion-ordered
parallel arrays (knownIds/knownValues) with NO per-tag Entry object — the
allocation lever. Lazily allocated on the first known-tag write; custom tags
stay in the hash buckets. Disjoint by construction (known-ness is global), so
read-through shadow checks stay within-region and the bucket path is unchanged.

- KnownTagCodec (id encoding + resolver) + hand-written KnownTags (keyOf
  substrate over StringIndex). Off-by-default: dormant until a resolver
  registers, so production is byte-identical.
- CoreTracer flips it live behind `-Ddd.trace.dense.tags.enabled` for A/B.
- Sizing is a generous fixed stopgap (KNOWN_INIT_CAP=12, the per-type max);
  exact per-type sizing comes with the tag registry.

Reconciled onto the level-split stack (fold + read-through + StringIndex);
built on the folded final-class TagMap.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…scan)

A per-map long bitmask (knownBloom) over the dense store: a set bit means a
tagId MAY be present (scan to confirm), a clear bit means DEFINITELY absent —
so the common per-build insert skips the linear knownIndexOf scan and appends
in O(1). Crude position->bit map (fieldPos & 63); a collision-minimizing
per-type coloring later only raises the hit rate — correctness never depends
on it because the scan stays authoritative. Superset semantics: set on add,
never cleared on remove (a stale bit costs a scan, never a wrong answer).

Alloc-neutral (one long field, no extra allocation); the win is insertion CPU,
moving the dense store toward HashMap insertion parity without the scan.

Reconciled onto the folded-class dense store (#11814).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@dougqh
dougqh force-pushed the dougqh/dense-bloom-filter branch from 267ebad to 56550cb Compare July 15, 2026 21:23
dougqh and others added 2 commits July 15, 2026 17:25
Replace the hand-written KnownTags with a build-time code generator: a
language-agnostic tag-conventions.yaml (span types via extends/include/applies)
+ a Java overlay (tag-conventions.java.yaml: intercepted hints + the virtual
registry) drive a buildSrc generator that emits KnownTags.java (id constants +
keyOf/nameOf resolver) to committed src/generated. A verifyKnownTags check
regenerates and byte-compares to catch stale commits; src/generated is excluded
from spotless (the emitter produces google-java-format-clean output).

- buildSrc: TagRegistryGeneratorPlugin + generate/verify tasks + parse/resolve
  (TagConventions) + id-assignment/coloring (TagRegistry) + emit (KnownTagsEmitter).
- KnownTagCodec gains the name-free 3-arg tagId(globalSerial, intercepted, slot)
  the generator uses.
- Generated per-type layout reports (resolved/tag-assignment/layout-by-type/
  folded-types) committed as auditable artifacts — the per-type counts are the
  substrate the (follow-on) per-type dense-array sizing reads.

Substrate only: this lands the generated ids/resolver + the codegen source of
truth; it does NOT yet change dense-array sizing (that per-type-sizing step is
the follow-on that climbs out of the alloc valley). Reconciled onto the dense +
bloom stack; generated ids cohere with the dense store (forked/fuzz green).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
set(long id, Object value) is the id-keyed insertion path: the caller passes a
resolved KnownTags id, so it skips the keyOf name resolution the set(String, ...)
methods pay and stores densely. The id must be a stored known-tag id; custom
names have no id and use the name-keyed setters. The name is resolved lazily only
to clear a read-through tombstone (rare).

Adds TagMapInsertionComparisonBenchmark (dense vs HashMap insertion, -prof gc) —
the isolated micro where the dense store + bloom insertion win shows, and the
home for the id-keyed vs string-keyed comparison.

Reconciled onto the tag registry (#11961).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@dougqh
dougqh changed the base branch from dougqh/dense-bloom-filter to dougqh/tag-registry-generator July 15, 2026 21:27
@dougqh
dougqh force-pushed the dougqh/tag-id-api branch from f0cb351 to 7e3ed31 Compare July 15, 2026 21:27
@datadog-prod-us1-6

datadog-prod-us1-6 Bot commented Jul 15, 2026

Copy link
Copy Markdown

🎯 Code Coverage (details)
Patch Coverage: 91.46%
Overall Coverage: 57.31% (-0.01%)

This comment will be updated automatically if new data arrives.
🔗 Commit SHA: 7e3ed31 | Docs | Datadog PR Page | Give us feedback!

@dougqh
dougqh force-pushed the dougqh/tag-registry-generator branch from ad3c1f7 to 369a591 Compare July 22, 2026 16:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp: core Tracer core tag: ai generated Largely based on code generated by an AI or LLM tag: no release notes Changes to exclude from release notes type: refactoring

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant